TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { ArrowRight, Brain, Eye, Link2, MessagesSquare, Scissors, Wrench } from "lucide-react";5import { getPublicShare, readShareMeta } from "@/lib/conversations/service";6import { LogoMark, Wordmark } from "@/components/brand/logo";7import { ProviderIcon } from "@/components/brand/provider-icon";8import { Badge } from "@/components/ui/badge";9import { Button } from "@/components/ui/button";10import { SimpleMarkdown } from "@/components/markdown/simple-markdown";11import { providerName } from "@/lib/client/providers";12import { formatMs, formatTokens, cn } from "@/lib/utils";1314export const dynamic = "force-dynamic";1516/* ------------------------------------------------------------------------------------------------17 * Snapshot shape (frozen copy written by `shareConversation`). Parsed defensively; the optional first18 * `$meta` element (v2) is skipped by the message parser and read separately with `readShareMeta`.19 * ---------------------------------------------------------------------------------------------- */20type SnapPart =21 | { type: "text"; text: string }22 | { type: "reasoning"; text: string; durationMs?: number }23 | { type: "tool-call"; id?: string; name: string; arguments?: Record<string, unknown>; argumentsText?: string; result?: unknown; isError?: boolean }24 | { type: "citation"; url?: string; title?: string; snippet?: string };2526interface SnapMessage {27 role: "user" | "assistant" | "system";28 content: string;29 modelKey: string | null;30 createdAt: string | null;31 parts: SnapPart[];32 usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number } | null;33 latencyMs: number | null;34}3536function isRecord(v: unknown): v is Record<string, unknown> {37 return typeof v === "object" && v !== null;38}3940function parseSnapshot(raw: unknown): SnapMessage[] {41 if (!Array.isArray(raw)) return [];42 const out: SnapMessage[] = [];43 for (const m of raw) {44 if (!isRecord(m)) continue;45 const role = m.role === "user" || m.role === "assistant" || m.role === "system" ? m.role : null;46 if (!role) continue;47 const parts: SnapPart[] = [];48 if (Array.isArray(m.parts)) {49 for (const p of m.parts) {50 if (!isRecord(p) || typeof p.type !== "string") continue;51 if ((p.type === "text" || p.type === "reasoning") && typeof p.text === "string") parts.push(p as SnapPart);52 else if (p.type === "tool-call" && typeof p.name === "string") parts.push(p as SnapPart);53 else if (p.type === "citation") parts.push(p as SnapPart);54 }55 }56 const createdAt = typeof m.createdAt === "string" ? m.createdAt : m.createdAt instanceof Date ? m.createdAt.toISOString() : null;57 out.push({58 role,59 content: typeof m.content === "string" ? m.content : "",60 modelKey: typeof m.modelKey === "string" ? m.modelKey : null,61 createdAt,62 parts,63 usage: isRecord(m.usage) ? (m.usage as SnapMessage["usage"]) : null,64 latencyMs: typeof m.latencyMs === "number" ? m.latencyMs : null,65 });66 }67 return out;68}6970function splitModelKey(key: string | null): { provider: string | null; model: string | null } {71 if (!key) return { provider: null, model: null };72 const [provider, ...rest] = key.split("/");73 return { provider: provider || null, model: rest.join("/") || key };74}7576function formatDate(d: Date) {77 return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });78}7980/* ------------------------------------------------------------------------------------------------81 * Metadata — noindex, per-share OG title82 * ---------------------------------------------------------------------------------------------- */83export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {84 const { id } = await params;85 const share = await getPublicShareSafe(id, { peek: true });86 const count = share ? parseSnapshot(share.snapshot).filter((m) => m.role !== "system").length : 0;87 const description = share ? `A conversation shared from PolyLLM · ${count} message${count === 1 ? "" : "s"}.` : "This shared conversation is unavailable.";88 return {89 title: share ? share.title : "Shared conversation",90 description,91 robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } },92 openGraph: share ? { title: `${share.title} · Shared from PolyLLM`, description, type: "article", siteName: "PolyLLM" } : undefined,93 twitter: share ? { card: "summary", title: `${share.title} · Shared from PolyLLM`, description } : undefined,94 };95}9697async function getPublicShareSafe(id: string, opts: { peek?: boolean } = {}) {98 if (!id || id.length > 128 || !/^[\w-]+$/.test(id)) return null;99 try {100 return await getPublicShare(id, opts);101 } catch {102 return null;103 }104}105106/* ------------------------------------------------------------------------------------------------107 * Page — mobile-first: 16px gutters, stacked header, full-width CTA; widens at sm/md.108 * ---------------------------------------------------------------------------------------------- */109export default async function SharePage({ params }: { params: Promise<{ id: string }> }) {110 const { id } = await params;111 const share = await getPublicShareSafe(id);112 if (!share) notFound();113114 const messages = parseSnapshot(share.snapshot).filter((m) => m.role !== "system");115 const meta = readShareMeta(share.snapshot);116 const models = Array.from(new Set(messages.map((m) => m.modelKey).filter((k): k is string => !!k)));117 const created = share.createdAt instanceof Date ? share.createdAt : new Date(share.createdAt as unknown as string);118 const views = share.viewCount + 1; // this visit was counted after the row was read119120 return (121 <div className="flex min-h-dvh flex-col bg-bg">122 <header className="glass sticky top-0 z-40 pt-[var(--sat)] hairline-b">123 <div className="mx-auto flex h-12 w-full max-w-3xl items-center justify-between gap-3 px-4 sm:h-14 sm:px-6">124 <Link href="/" className="flex min-w-0 items-center gap-2 rounded-md" aria-label="PolyLLM home">125 <LogoMark size={24} />126 <Wordmark className="text-[16px]" />127 <span className="hidden truncate text-[12.5px] text-fg-subtle sm:inline">· Shared conversation</span>128 </Link>129 <Button asChild size="sm" className="shrink-0">130 <Link href="/signup">131 Open PolyLLM132 <ArrowRight />133 </Link>134 </Button>135 </div>136 </header>137138 <main className="mx-auto w-full max-w-3xl flex-1 px-4 pb-14 pt-6 sm:px-6 sm:pt-10">139 <header className="pb-5 hairline-b sm:pb-6">140 <p className="flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.14em] text-fg-subtle">141 <MessagesSquare className="size-3.5" aria-hidden />142 Shared from PolyLLM143 {meta?.partial ? (144 <Badge variant="accent" className="ml-1 normal-case tracking-normal">145 <Scissors />146 Excerpt · {meta.selectedCount} of {meta.totalCount}147 </Badge>148 ) : null}149 </p>150 <h1 className="mt-2 text-balance text-[24px] font-semibold leading-tight tracking-tight sm:text-3xl">{share.title}</h1>151 <dl className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[13px] text-fg-muted">152 <div className="flex items-center gap-1.5">153 <dt className="sr-only">Shared on</dt>154 <dd>155 <time dateTime={created.toISOString()}>{formatDate(created)}</time>156 </dd>157 </div>158 <span aria-hidden>·</span>159 <div>160 <dt className="sr-only">Messages</dt>161 <dd>162 {messages.length} message{messages.length === 1 ? "" : "s"}163 </dd>164 </div>165 <span aria-hidden>·</span>166 <div className="flex items-center gap-1 tabular-nums">167 <Eye className="size-3.5 text-fg-subtle" aria-hidden />168 <dt className="sr-only">Views</dt>169 <dd>170 {views} view{views === 1 ? "" : "s"}171 </dd>172 </div>173 </dl>174 {models.length ? (175 <ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Models used">176 {models.map((k) => {177 const { provider, model } = splitModelKey(k);178 return (179 <li key={k}>180 <Badge className="h-7 px-2.5 text-[12px]">181 <ProviderIcon provider={provider} size={13} />182 {model}183 </Badge>184 </li>185 );186 })}187 </ul>188 ) : null}189 </header>190191 <ol className="mt-6 space-y-6 sm:mt-8 sm:space-y-7">192 {messages.map((m, i) => (193 <li key={i} id={`m-${i + 1}`}>194 <MessageView message={m} />195 </li>196 ))}197 </ol>198199 <aside className="panel mt-12 p-5 text-center sm:mt-14 sm:p-8">200 <div className="mx-auto flex size-10 items-center justify-center">201 <LogoMark size={36} />202 </div>203 <p className="mt-3 text-balance text-[17px] font-semibold tracking-tight sm:text-lg">Continue this conversation with your own keys</p>204 <p className="mx-auto mt-2 max-w-md text-balance text-[14px] leading-6 text-fg-muted">One interface, every model. Bring your OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter or Cerebras key — chat, compare in the Arena and track every token.</p>205 <div className="mt-5 flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center">206 <Button asChild size="lg" className="w-full sm:w-auto">207 <Link href="/signup">208 Open PolyLLM209 <ArrowRight />210 </Link>211 </Button>212 <Button asChild size="lg" variant="ghost" className="w-full sm:w-auto">213 <Link href="/">Learn more</Link>214 </Button>215 </div>216 </aside>217 </main>218219 <footer className="px-4 pb-[max(20px,var(--sab))] pt-6 text-center text-xs text-fg-subtle hairline-t">220 <p className="text-balance">221 Frozen snapshot shared by a PolyLLM user · attachments are not included · model output can be wrong.{" "}222 <Link href="/privacy" className="underline-offset-4 hover:text-fg hover:underline">223 Privacy224 </Link>225 {" · "}226 <Link href="/terms" className="underline-offset-4 hover:text-fg hover:underline">227 Terms228 </Link>229 </p>230 </footer>231 </div>232 );233}234235/* ------------------------------------------------------------------------------------------------236 * Message rendering237 * ---------------------------------------------------------------------------------------------- */238function MessageView({ message }: { message: SnapMessage }) {239 if (message.role === "user") {240 const text = message.parts.filter((p): p is Extract<SnapPart, { type: "text" }> => p.type === "text").map((p) => p.text).join("\n\n") || message.content;241 return (242 <div className="flex justify-end">243 <div className="max-w-[92%] rounded-2xl rounded-br-md bg-bg-muted px-4 py-2.5 text-[15px] leading-7 sm:max-w-[80%]">244 <p className="sr-only">You said:</p>245 <SimpleMarkdown>{text}</SimpleMarkdown>246 </div>247 </div>248 );249 }250251 const { provider, model } = splitModelKey(message.modelKey);252 const reasoning = message.parts.filter((p): p is Extract<SnapPart, { type: "reasoning" }> => p.type === "reasoning" && p.text.trim().length > 0);253 const tools = message.parts.filter((p): p is Extract<SnapPart, { type: "tool-call" }> => p.type === "tool-call");254 const citations = message.parts.filter((p): p is Extract<SnapPart, { type: "citation" }> => p.type === "citation" && !!p.url);255 const textParts = message.parts.filter((p): p is Extract<SnapPart, { type: "text" }> => p.type === "text");256 const text = textParts.map((p) => p.text).join("\n\n") || message.content;257 const totalTokens = message.usage?.totalTokens ?? ((message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0) || null);258259 return (260 <article className="flex gap-2.5 sm:gap-3">261 <span className="mt-1 flex size-7 shrink-0 items-center justify-center rounded-md border border-border bg-bg-elevated" aria-hidden>262 <ProviderIcon provider={provider} size={14} />263 </span>264 <div className="min-w-0 flex-1">265 <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[13px]">266 <span className="font-medium">{model ?? "Assistant"}</span>267 {provider ? <span className="text-fg-subtle">{providerName(provider)}</span> : null}268 </div>269270 {reasoning.length ? (271 <details className="group mt-2 rounded-lg border border-border bg-bg-subtle/60">272 <summary className="flex min-h-[40px] cursor-pointer select-none items-center gap-2 px-3 py-2 text-[13px] text-fg-muted marker:content-none [&::-webkit-details-marker]:hidden">273 <Brain className="size-3.5" aria-hidden />274 Reasoning275 {reasoning[0].durationMs ? <span className="font-mono text-[11px] text-fg-subtle">· {formatMs(reasoning[0].durationMs)}</span> : null}276 <span className="ml-auto text-[11px] text-fg-subtle group-open:hidden">Show</span>277 <span className="ml-auto hidden text-[11px] text-fg-subtle group-open:inline">Hide</span>278 </summary>279 <div className="border-t border-border px-3 py-3 text-[13px] leading-6 text-fg-muted">280 <SimpleMarkdown className="text-[13px]">{reasoning.map((r) => r.text).join("\n\n")}</SimpleMarkdown>281 </div>282 </details>283 ) : null}284285 {tools.length ? (286 <ul className="mt-2 space-y-1.5">287 {tools.map((t, i) => (288 <li key={t.id ?? i} className="rounded-lg border border-border bg-bg-subtle/60 px-3 py-2">289 <div className="flex items-center gap-2 text-[12.5px]">290 <Wrench className="size-3.5 text-fg-subtle" aria-hidden />291 <span className="font-mono font-medium">{t.name}</span>292 {t.isError ? <Badge variant="danger">error</Badge> : null}293 </div>294 {t.argumentsText || t.arguments ? <pre className="mt-1.5 overflow-x-auto font-mono text-[11.5px] leading-5 text-fg-muted scrollbar-thin">{t.argumentsText ?? JSON.stringify(t.arguments, null, 2)}</pre> : null}295 </li>296 ))}297 </ul>298 ) : null}299300 <div className={cn("mt-2", !text && "text-[14px] italic text-fg-subtle")}>{text ? <SimpleMarkdown>{text}</SimpleMarkdown> : "No text in this message."}</div>301302 {citations.length ? (303 <ol className="mt-3 space-y-1">304 {citations.map((c, i) => (305 <li key={`${c.url}-${i}`} className="flex items-start gap-2 text-[12.5px]">306 <Link2 className="mt-1 size-3.5 shrink-0 text-fg-subtle" aria-hidden />307 <a href={c.url} target="_blank" rel="noopener noreferrer nofollow" className="min-w-0 truncate text-accent underline-offset-4 hover:underline">308 {c.title || c.url}309 </a>310 </li>311 ))}312 </ol>313 ) : null}314315 {totalTokens || message.latencyMs ? (316 <p className="mt-2.5 font-mono text-[11px] tabular-nums text-fg-subtle">317 {totalTokens ? `${formatTokens(totalTokens)} tokens` : null}318 {totalTokens && message.latencyMs ? " · " : null}319 {message.latencyMs ? formatMs(message.latencyMs) : null}320 </p>321 ) : null}322 </div>323 </article>324 );325}326